2. Lighting

LearnOpenGL - PBR - Lighting
이전 챕터에서 현실적인 물리 기반 렌더러(PBR)를 구축하기 위한 기초를 다졌습니다.
이번 챕터에서는 이전에 다룬 이론을 실제 렌더러에 적용하여 직접 조명(Direct Lighting) 을 구현하는 방법을 살펴보겠습니다.
직접 조명에는 점광(Point Light), 방향광(Directional Light), 스포트라이트(Spotlight) 등이 포함됩니다.

우선, 이전 챕터에서 정리한 최종 반사 방정식을 다시 살펴보겠습니다.
4. Archive/WhiteEngine/PBR/Attachments/Pasted image 20250313145900.png
이제 이 방정식의 대부분을 이해했지만, 여전히 휘조도(irradiance) 또는 장면의 총 방사휘도(total radiance, L) 를 어떻게 표현할 것인지가 남아 있습니다.
컴퓨터 그래픽에서는 방사휘도 L 는 광원의 방사 플럭스(ϕ) 또는 광에너지를 주어진 입체각(ω)에서 측정한 값 으로 해석합니다.
우리는 입체각(ω)이 무한히 작은 경우를 가정했으므로, 방사휘도는 특정 광선(ray) 또는 방향 벡터(direction vector)에서의 방사 플럭스를 측정한 값 이 됩니다.

이제 이 개념을 우리가 이전에 배운 조명 모델과 어떻게 연결할 수 있을까요?
예를 들어, 하나의 점광(Point Light) 을 생각해봅시다.
이 광원이 모든 방향으로 균등하게 빛을 방출한다고 가정하고, 광원의 방사 플럭스(ϕ)가 RGB 삼원색 값으로 (23.47, 21.31, 20.79)라고 가정합니다.
이 경우, 광원의 방사 강도(Radiant Intensity) 는 모든 방향으로 동일합니다.

그러나 특정 표면의 한 점 ppp 를 셰이딩할 때, 반구(hemisphere) Ω의 모든 가능한 입사광 방향 중에서 단 하나의 입사 방향 ωi\omega_iωi​ 만이 점광원에서 직접 도달하는 방향이 됩니다.
즉, 장면에 하나의 점광원만 존재한다고 가정하면, 다른 모든 입사 방향에서 오는 광휘도는 0이 됩니다.
4. Archive/WhiteEngine/PBR/Attachments/Pasted image 20250313151625.png처음에는 점광원(Point Light)
처음에는 광 감쇠(light attenuation, 거리 증가에 따른 빛의 감쇠) 가 점광원에 영향을 미치지 않는다고 가정하면,
입사광선의 방사휘도는 광원의 위치에 관계없이 동일합니다.
(단, 입사각의 코사인 값 cos⁡θ 에 의해 방사휘도가 조정되는 경우는 제외)
이는 점광원이 방사 강도(Radiant Intensity) 가 모든 방향에서 일정하기 때문이며,
결과적으로 점광원의 방사 강도방사 플럭스(Radiant Flux) 와 동일한 상수 벡터로 모델링할 수 있습니다:
(23.47,21.31,20.79)(23.47, 21.31, 20.79)(23.47,21.31,20.79).

하지만 방사휘도(L)는 위치 ppp 를 입력값으로 사용 하며,
실제 점광원은 광 감쇠 를 고려해야 하므로,
점광원의 방사 강도표면의 점 ppp 와 광원 사이의 거리 에 의해 조정됩니다.
그리고 원래의 방사휘도 방정식 에 따라, 결과는 표면 법선 nnn 과 입사광 방향 ωi\omega_iωi​ 의 내적 에 의해 추가로 조정됩니다.

이를 보다 실용적인 관점에서 보면, 점광원(Point Light) 의 경우,
방사휘도 LLL 는 광원의 색상거리 감쇠 에 따라 감소시키고,
n⋅ωi​ 값 에 따라 조정하여 최종적으로 p 에 도달하는 단 하나의 광선 방향 ωi 에 대해서만 계산됩니다.
이 광선 방향 ωi 는 점 p 에서 광원으로 향하는 방향 벡터 와 같습니다.

코드로 표현하면 다음과 같습니다:

vec3  lightColor  = vec3(23.47, 21.31, 20.79);
vec3  wi          = normalize(lightPos - fragPos);
float cosTheta    = max(dot(N, wi), 0.0);
float attenuation = calculateAttenuation(fragPos, lightPos);
vec3  radiance    = lightColor * attenuation * cosTheta;

용어는 조금 다르지만, 이 코드는 우리가 지금까지 구현했던 난반사(Diffuse Lighting) 코드와 거의 동일합니다.
즉, 직접 조명(Direct Lighting)의 경우 단 하나의 광선 방향 벡터 만이 표면의 방사휘도 계산에 기여하므로,
기존의 조명 모델과 유사한 방식으로 방사휘도를 계산할 수 있습니다.

이 가정은 점광원이 무한히 작으며 공간에서 단 하나의 점으로 존재하기 때문에 성립한다. 만약 우리가 면적이나 부피를 가진 광원을 모델링한다면, 하나 이상의 입사 광 방향에서 방사휘도가 0이 아닐 것이다.

다른 유형의 점광원에서도 방사휘도를 계산하는 방법은 유사합니다. 예를 들어, 방향광원(directional light source)은 감쇠(attenuation) 없이 일정한 wi​ 값을 가지며, 스포트라이트(spotlight)의 경우에는 방사 강도(radiant intensity)가 일정하지 않고 스포트라이트의 전방 방향 벡터에 의해 조정됩니다.

이는 또한 표면의 반구 Ω 에 대한 적분 ∫ 으로 다시 연결됩니다. 단일 표면 지점을 셰이딩할 때 기여하는 모든 광원의 위치를 사전에 알고 있기 때문에, 적분을 직접적으로 해결할 필요는 없습니다. 알려진 광원의 개수를 기반으로 총 조사휘도(irradiance)를 직접 계산할 수 있으며, 각 광원은 단 하나의 입사광 방향만이 표면의 방사휘도에 영향을 미치기 때문입니다.

이러한 특성 덕분에 직접광에 대한 PBR은 비교적 간단합니다. 단순히 기여하는 광원들을 순회하면서 계산하면 됩니다. 그러나 이후 IBL(이미지 기반 조명) 챕터에서 환경 조명을 고려할 때는 빛이 모든 방향에서 올 수 있으므로 적분을 수행해야 합니다.

A PBR surface model

먼저 이전에 설명한 PBR 모델을 구현하는 프래그먼트 셰이더를 작성해 보겠습니다. 이를 위해 표면을 셰이딩하는 데 필요한 관련 PBR 입력값을 가져와야 합니다:

#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
in vec3 WorldPos;
in vec3 Normal;
  
uniform vec3 camPos;
  
uniform vec3  albedo;
uniform float metallic;
uniform float roughness;
uniform float ao;

우리는 일반적인 정점 셰이더에서 계산된 표준 입력값과 객체 표면에 대한 일정한 재질 속성 집합을 가져옵니다.

그런 다음 프래그먼트 셰이더의 시작 부분에서 모든 조명 알고리즘에 필요한 일반적인 계산을 수행합니다:

void main()
{
    vec3 N = normalize(Normal); 
    vec3 V = normalize(camPos - WorldPos);
    [...]
}

Directional Light

이 장의 예제 데모에서는 총 4개의 점광원이 있어 함께 장면의 방사휘도를 나타냅니다. 반사 방정식을 만족시키기 위해 우리는 각 광원에 대해 루프를 돌며 개별 방사휘도를 계산하고, 그것을 BRDF와 광원의 입사 각도로 스케일링하여 기여도를 합산합니다. 우리는 이 루프를 직접 광원에 대해 Ω에 대해 적분을 푸는 과정으로 생각할 수 있습니다. 먼저, 각 광원에 대한 관련 변수를 계산합니다:

vec3 Lo = vec3(0.0);
for(int i = 0; i < 4; ++i) 
{
    vec3 L = normalize(lightPositions[i] - WorldPos);
    vec3 H = normalize(V + L);
  
    float distance    = length(lightPositions[i] - WorldPos);
    float attenuation = 1.0 / (distance * distance);
    vec3 radiance     = lightColors[i] * attenuation; 
    [...]  
}

우리는 선형 공간에서 조명을 계산하며 (셰이더 끝에서 감마 보정을 할 예정), 더 물리적으로 정확한 제곱 역 법칙에 따라 광원들을 감쇠시킵니다.

물리적으로 정확하지만, 여전히 상수-선형-제곱 감쇠 방정식을 사용하고 싶을 수 있습니다. 이 방정식은 (물리적으로 정확하지 않지만) 광원의 에너지 감소를 더 많이 제어할 수 있게 해줍니다.

물리적으로 정확한 계산을 위해, 각 광원에 대해 Cook-Torrance 스펙큘러 BRDF 항목을 계산하려면 다음을 수행해야 합니다:
4. Archive/WhiteEngine/PBR/Attachments/Pasted image 20250313155657.png
먼저 해야 할 일은 표면이 얼마나 많은 빛을 반사하는지, 또는 얼마나 많은 빛을 굴절시키는지를 나타내는 스펙큘러 반사와 디퓨즈 반사의 비율을 계산하는 것입니다. 이전 장에서 우리는 프레넬 방정식이 이를 계산한다는 것을 알았습니다 (여기서 검은 점을 방지하기 위해 클램프를 사용하는 것을 주목하십시오):

vec3 fresnelSchlick(float cosTheta, vec3 F0)
{
    return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

프레넬-슐릭 근사법은 F0 파라미터를 기대합니다. F0는 제로 입사각에서 표면의 반사율을 나타내며, 표면을 직접 볼 때 표면이 얼마나 반사하는지를 나타냅니다. F0는 재질마다 다르며, 금속의 경우 큰 재질 데이터베이스에서 F0를 색조로 표현할 수 있습니다. PBR 금속 워크플로우에서는 대부분의 유전체 표면이 시각적으로 정확하게 보이도록 F0를 0.04로 설정하는 단순화된 가정을 사용하고, 금속 표면에서는 F0 값을 알베도 값으로 지정합니다. 이는 코드로 다음과 같이 변환됩니다:

vec3 F0 = vec3(0.04); 
F0      = mix(F0, albedo, metallic);
vec3 F  = fresnelSchlick(max(dot(H, V), 0.0), F0);

보시다시피, 비금속 표면의 경우 F0는 항상 0.04입니다. 금속 표면의 경우, F0는 알베도 값에 따라 선형 보간법으로 변경됩니다.

F가 구해지면, 나머지 항목인 노멀 분포 함수(D)와 기하학 함수(G)를 계산해야 합니다.

직접 PBR 조명 셰이더에서는 이러한 함수들의 코드 구현은 다음과 같습니다:

float DistributionGGX(vec3 N, vec3 H, float roughness)
{
    float a      = roughness*roughness;
    float a2     = a*a;
    float NdotH  = max(dot(N, H), 0.0);
    float NdotH2 = NdotH*NdotH;
	
    float num   = a2;
    float denom = (NdotH2 * (a2 - 1.0) + 1.0);
    denom = PI * denom * denom;
	
    return num / denom;
}

float GeometrySchlickGGX(float NdotV, float roughness)
{
    float r = (roughness + 1.0);
    float k = (r*r) / 8.0;

    float num   = NdotV;
    float denom = NdotV * (1.0 - k) + k;
	
    return num / denom;
}

float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness)
{
    float NdotV = max(dot(N, V), 0.0);
    float NdotL = max(dot(N, L), 0.0);
    float ggx2  = GeometrySchlickGGX(NdotV, roughness);
    float ggx1  = GeometrySchlickGGX(NdotL, roughness);
	
    return ggx1 * ggx2;
}

여기서 중요한 점은 이론 장과 달리, 우리는 직접 거친 정도(roughness) 값을 이 함수들에 전달한다는 것입니다. 이를 통해 원래의 거친 정도 값을 특정 항목별로 수정할 수 있습니다. 디즈니의 관찰 결과에 따라, Epic Games에서 채택한 방식은 기하학 함수와 노멀 분포 함수 모두에서 거친 정도를 제곱하여 계산할 때 더 정확한 결과를 제공합니다.

두 함수가 정의되면, 반사 루프에서 NDF와 G 항목을 계산하는 것은 간단합니다:

float NDF = DistributionGGX(N, H, roughness);       
float G   = GeometrySmith(N, V, L, roughness);       

이제 Cook-Torrance BRDF를 계산할 수 있습니다:

vec3 numerator    = NDF * G * F;
float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0)  + 0.0001;
vec3 specular     = numerator / denominator;

분모에 0.0001을 추가하는 이유는 어떤 내적이 0.0이 될 경우 0으로 나누는 것을 방지하기 위함입니다.

이제 각 광원이 반사 방정식에 기여하는 값을 계산할 수 있습니다. 프레넬 값은 직접적으로 kS에 해당하므로, F를 사용하여 표면에 영향을 미치는 각 광원의 스펙큘러 기여도를 나타낼 수 있습니다. kS를 사용하여 굴절 비율 kD를 계산할 수 있습니다:

vec3 kS = F;
vec3 kD = vec3(1.0) - kS;

kD *= 1.0 - metallic;

kS는 반사된 빛의 에너지를 나타내므로, 나머지 빛 에너지는 굴절된 빛 에너지로 남으며, 이는 kD에 저장됩니다. 또한, 금속 표면은 빛을 굴절시키지 않기 때문에, kD를 0으로 설정하여 이 속성을 강제합니다. 이를 통해 각 광원의 최종 반사 값을 계산하는 데 필요한 데이터를 모두 얻을 수 있습니다:

const float PI = 3.14159265359;

float NdotL = max(dot(N, L), 0.0);        
Lo += (kD * albedo / PI + specular) * radiance * NdotL;

결과적으로 얻어진 Lo 값, 즉 방출된 방사휘도는 사실상 반사 방정식의 적분 ∫ Ω에 해당하는 결과입니다. 우리는 모든 가능한 입사 광선 방향에 대해 적분을 해결하려고 할 필요는 없으며, 각 광원이 표면에 미치는 영향을 정확히 알기 때문에, 장면에 있는 광원의 수만큼 직접 루프를 돌리면 됩니다.

마지막으로, (즉흥적인) 환경 항목을 직접 조명 결과 Lo에 추가하고, 최종적으로 프래그먼트의 조명 색을 계산할 수 있습니다:

vec3 ambient = vec3(0.03) * albedo * ao;
vec3 color   = ambient + Lo;

Linear and HDR rendering

지금까지 우리는 모든 계산이 선형 색 공간에서 이루어지고 있다고 가정했습니다. 이를 반영하기 위해 셰이더 끝에서 감마 보정을 수행해야 합니다. 선형 색 공간에서의 조명 계산은 매우 중요합니다. 왜냐하면 PBR은 모든 입력 값이 선형이어야 하기 때문입니다. 이를 고려하지 않으면 잘못된 조명이 발생합니다. 또한, 우리는 빛의 입력 값들이 물리적으로 적절한 값들에 가까워지도록 하고 싶습니다. 즉, 방사휘도나 색 값들이 매우 넓은 범위의 값들로 변화할 수 있어야 합니다. 그 결과, Lo는 빠르게 매우 커질 수 있고, 그 후 기본적인 낮은 동적 범위(LDR) 출력으로 인해 0.0과 1.0 사이로 클램프됩니다. 우리는 이를 해결하기 위해 Lo 값을 HDR 값을 LDR로 올바르게 톤 맵핑하고 감마 보정합니다:

color = color / (color + vec3(1.0));
color = pow(color, vec3(1.0/2.2));   // color가 0 ~ 1의 값이기 때문에 색상이 밝아짐

여기서 우리는 Reinhard 연산자를 사용하여 HDR 색을 톤 맵핑하고, 가능성 있는 매우 변화하는 방사휘도의 고동적(high dynamic range) 범위를 유지한 후 색을 감마 보정합니다. 별도의 프레임버퍼나 후처리 단계가 없기 때문에, 우리는 포워드 프래그먼트 셰이더의 끝에서 톤 맵핑과 감마 보정 단계를 직접 적용할 수 있습니다.
4. Archive/WhiteEngine/PBR/Attachments/Pasted image 20250313162003.png
선형 색 공간과 고동적 범위를 모두 고려하는 것은 PBR 파이프라인에서 매우 중요합니다. 이 두 가지 없이 다양한 빛의 강도의 높은 세부 사항과 낮은 세부 사항을 적절하게 캡처하는 것이 불가능하며, 그 결과 계산이 잘못되어 시각적으로 불쾌한 결과가 나타납니다.

Full direct lighting PBR shader

#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
in vec3 WorldPos;
in vec3 Normal;

// 재질 파라미터
uniform vec3  albedo;
uniform float metallic;
uniform float roughness;
uniform float ao;

// 조명
uniform vec3 lightPositions[4];
uniform vec3 lightColors[4];

uniform vec3 camPos;

const float PI = 3.14159265359;

float DistributionGGX(vec3 N, vec3 H, float roughness);
float GeometrySchlickGGX(float NdotV, float roughness);
float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness);
vec3 fresnelSchlick(float cosTheta, vec3 F0);

void main()
{		
    vec3 N = normalize(Normal);
    vec3 V = normalize(camPos - WorldPos);

    vec3 F0 = vec3(0.04); 
    F0 = mix(F0, albedo, metallic);
	           
    // 반사 방정식
    vec3 Lo = vec3(0.0);
    for(int i = 0; i < 4; ++i) 
    {
        // 각 조명에 대한 방사선 계산
        vec3 L = normalize(lightPositions[i] - WorldPos);
        vec3 H = normalize(V + L);
        float distance    = length(lightPositions[i] - WorldPos);
        float attenuation = 1.0 / (distance * distance);
        vec3 radiance     = lightColors[i] * attenuation;        
        
        // 쿠크-토렌스 BRDF
        float NDF = DistributionGGX(N, H, roughness);        
        float G   = GeometrySmith(N, V, L, roughness);      
        vec3 F    = fresnelSchlick(max(dot(H, V), 0.0), F0);       
        
        vec3 kS = F;
        vec3 kD = vec3(1.0) - kS;
        kD *= 1.0 - metallic;	  

        vec3 numerator    = NDF * G * F;
        float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.0001;
        vec3 specular     = numerator / denominator;  
            
        // 방사선 Lo에 추가
        float NdotL = max(dot(N, L), 0.0);                
        Lo += (kD * albedo / PI + specular) * radiance * NdotL; 
    }   
  
    vec3 ambient = vec3(0.03) * albedo * ao;
    vec3 color = ambient + Lo;
	
    color = color / (color + vec3(1.0));
    color = pow(color, vec3(1.0/2.2));  
   
    FragColor = vec4(color, 1.0);
}

이전 장의 이론과 반사 방정식에 대한 지식을 바탕으로, 이 쉐이더는 이제 더 이상 어렵지 않게 느껴질 것입니다. 이 쉐이더와 4개의 포인트 라이트, 그리고 수직 및 수평 축에 따라 메탈릭과 거칠기 값을 다양하게 변경한 몇 개의 구체를 사용하면, 다음과 같은 결과를 얻을 수 있습니다:
4. Archive/WhiteEngine/PBR/Attachments/Pasted image 20250313171732.png
전체 소스코드 데모

Textured PBR

시스템을 이제 표면 매개변수를 유니폼 값 대신 텍스처로 받아들이도록 확장하면, 표면 재질의 속성에 대해 프래그먼트 단위로 제어할 수 있게 됩니다:

uniform sampler2D albedoMap;
uniform sampler2D normalMap;
uniform sampler2D metallicMap;
uniform sampler2D roughnessMap;
uniform sampler2D aoMap;
  
void main()
{
    vec3 albedo     = pow(texture(albedoMap, TexCoords).rgb, 2.2);
    vec3 normal     = getNormalFromNormalMap();
    float metallic  = texture(metallicMap, TexCoords).r;
    float roughness = texture(roughnessMap, TexCoords).r;
    float ao        = texture(aoMap, TexCoords).r;
    [... ]
}

여기서 아티스트들이 사용하는 알베도 텍스처는 일반적으로 sRGB 색 공간에서 작성되므로, 알베도를 조명 계산에 사용하기 전에 선형 색 공간으로 변환해야 합니다. 환경 차폐(ambient occlusion) 맵을 생성하는 시스템에 따라 이러한 텍스처들도 sRGB에서 선형 색 공간으로 변환해야 할 수도 있습니다. 메탈릭과 거칠기 맵은 거의 항상 선형 색 공간에서 작성됩니다.

이전의 구들에 대한 재질 속성들을 텍스처로 대체한 것만으로도, 우리가 사용했던 이전의 조명 알고리즘보다 상당한 시각적 개선이 이루어진 것을 볼 수 있습니다.
4. Archive/WhiteEngine/PBR/Attachments/Pasted image 20250313171946.png
텍스처가 적용된 데모의 전체 소스 코드는 여기에서 찾을 수 있으며, 사용된 텍스처 세트는 여기에서 확인할 수 있습니다 (흰색 환경 차폐(ao) 맵 포함). 메탈릭 재질은 확산 반사(diffuse reflectance)가 없기 때문에 직선 조명 환경에서 너무 어두워 보이는 경향이 있음을 명심하세요. 환경의 스펙큘러 앰비언트 조명을 고려하면 더 정확하게 보이게 되며, 이는 다음 장들에서 다룰 내용입니다.

IBL(이미지 기반 조명)이 아직 구현되지 않았기 때문에 일부 PBR 렌더 데모에 비해 시각적으로 인상적이지는 않지만, 우리가 현재 갖춘 시스템은 여전히 물리 기반 렌더러입니다. IBL이 없어도 조명이 훨씬 더 현실적으로 보일 것입니다.